<?php

namespace Gnway\Util\Concurrent;

use LogicException, RuntimeException, InvalidArgumentException;

/**
 * Class Process
 *
 * @author zushaolong
 * @mail zushaolong@gmail.com
 */
class Process implements Runnable
{

  /**
   * The runnable
   *
   * @var Runnable|null
   */
  protected ?Runnable $target;

  /**
   * The process pid
   *
   * @var int|null
   */
  protected ?int $pid = NULL;

  /**
   * The process name
   *
   * @var string|null
   */
  protected ?string $name = NULL;

  /**
   * Running flag of the process
   *
   * @var bool
   */
  protected bool $running = FALSE;

  /**
   * Interrupted flag of the process
   *
   * @var bool
   */
  protected bool $interrupted = FALSE;

  /**
   * Signal handler
   *
   * @var callable[]
   */
  protected array $signalHandler = [];

  /**
   * Current process
   *
   * @var Process[]
   */
  protected static array $process = [];

  /**
   * Process constructor.
   *
   * @param Runnable|NULL $target
   */
  public function __construct(Runnable $target = NULL)
  {
    $this->target = $target;

    $this->init();
  }

  /**
   * Init status
   *
   * @return void
   */
  protected function init()
  {
    $this->pid = NULL;
    $this->name = NULL;
    $this->running = FALSE;
    $this->interrupted = FALSE;
    $this->signalHandler = [];
  }

  /** @inheritDoc */
  public function run(): void
  {
    if ($this->target !== NULL) {
      $this->target->run();
    }
  }

  /**
   * Start the process
   *
   * @return void
   */
  public function start(): void
  {
    if ($this->isRunning()) {
      throw new LogicException('The process already running!');
    }

    $pid = pcntl_fork();
    if ($pid < 0) {
      throw new RuntimeException('Cannot fork child process.');
    } elseif ($pid > 0) {
      $this->pid = $pid;
      $this->running = TRUE;
    } else {
      if ($this->name && function_exists('cli_set_process_title')) {
        cli_set_process_title($this->name);
      }
      $this->running = TRUE;
      $this->pid = getmypid();
      static::$process[$this->pid] = $this;
      $this->listenSignal();
      $this->run();
      $this->init();
      unset(static::$process[$this->pid]);

      exit(0);
    }
  }

  /**
   * Wait process finish
   *
   * @param int $timeout
   * @return void
   * @throws WaitException
   */
  public function wait(int $timeout = 0): void
  {
    if (!$this->isRunning()) {
      return;
    }

    if ($timeout < -1) {
      throw new InvalidArgumentException('timeout value is illegal');
    }

    if ($timeout === 0) {
      $pid = pcntl_waitpid($this->pid, $status);
    } elseif ($timeout > 0) {
      $now = time();
      do {
        $pid = pcntl_waitpid($this->pid, $status, WNOHANG | WUNTRACED);
        usleep(10);
      } while (time() - $now < $timeout && $pid === 0);
    } else {
      $pid = pcntl_waitpid($this->pid, $status, WNOHANG | WUNTRACED);
    }

    if ($pid < 0) {
      throw new WaitException('pcntl_waitpid failed, the process maybe available.');
    } elseif ($pid > 0) {
      $this->init();
    } elseif ($timeout > 0) {
      throw new WaitException('Wait process timeout ' . $timeout . ' sec');
    }
  }

  /**
   * Shutdown the process
   *
   * @param int $signal
   * @return void
   */
  public function shutdown(int $signal = SIGTERM): void
  {
    if (!$this->isRunning() || $this->pid < 0) {
      throw new RuntimeException('This process is not running.');
    }
    if (!posix_kill($this->pid, $signal)) {
      throw new RuntimeException('Failed to kill the process.');
    }
    $this->wait();
  }

  /**
   * Listen the process signal
   *
   * @return void
   */
  protected function listenSignal(): void
  {
    pcntl_async_signals(TRUE);

    pcntl_signal(SIGTERM, function () {
      $this->interrupted = TRUE;
    });

    if ($this->signalHandler) {
      foreach ($this->signalHandler as $signal => $handler) {
        pcntl_signal($signal, $handler);
      }
    }
  }

  /**
   * Current process
   *
   * Notice: Only use in the current process
   *
   * @return Process
   */
  public static function currentProcess(): Process
  {
    $pid = getmypid();
    if (!static::$process[$pid]) {
      throw new RuntimeException('No current process found.');
    }

    return static::$process[$pid];
  }

  /**
   * @param string|null $name
   */
  public function setName(string $name): void
  {
    if ($this->isRunning()) {
      throw new RuntimeException('The process is running, cannot to set name');
    }
    $name = trim($name);
    $name !== '' && $this->name = $name;
  }

  /**
   * Register signal handler for process
   *
   * @param int $signal
   * @param callable $handler
   * @return $this
   */
  public function registerSignalHandler(int $signal, callable $handler): Process
  {
    if ($this->isRunning()) {
      throw new RuntimeException('The process is running, cannot to register signal handler');
    }

    $this->signalHandler[$signal] = $handler;

    return $this;
  }

  /**
   * @return string|null
   */
  public function getName(): ?string
  {
    return $this->name;
  }

  /**
   * @return int
   */
  public function getPid(): int
  {
    return $this->pid;
  }

  /**
   * @return bool
   */
  public function isRunning(): bool
  {
    return $this->running;
  }

  /**
   * @return bool
   */
  public function isInterrupted(): bool
  {
    return $this->interrupted;
  }

  /**
   * Interrupt current process
   *
   * @return void
   */
  public function interrupt(): void
  {
    if (!$this->isRunning()) {
      throw new RuntimeException('The process is not running, cannot to interrupt the process');
    }

    $this->interrupted = TRUE;
  }
}